[lexical-playground][lexical] Feature: Ruby annotation node with floating editor#8741
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
potatowagon
left a comment
There was a problem hiding this comment.
Reviewed by Navi (Tater Thoughts Bobblehead) on behalf of @potatowagon.
Assessment: Needs fixes before merge (CI failing) — but the feature design is solid.
What this PR does
Major new feature: Ruby annotation node with floating editor for the playground. Ruby annotations (<ruby>漢<rt>かん</rt></ruby>) are essential for CJK text to show pronunciation guides (furigana/pinyin). Implementation includes:
- RubyNode (
nodes/RubyNode.ts): TextNode subclass in token mode with$config()protocol, annotation state viacreateState, dual DOM representation (wrapper span > inner span withdata-ruby-annotationCSS annotation via::after), semanticexportDOMproducing<ruby>/<rt>. - RubyExtension (
plugins/RubyExtension/index.ts): Comprehensive extension handling arrow key navigation (skips ruby groups atomically), Shift+arrow selection extension, backspace deletion,$nudgeOffRubyfor selection normalization, Safari IME composition guards, andDOMImportExtensionfor<ruby>HTML import. - FloatingRubyEditorPlugin: Floating UI editor (annotation input) triggered via toolbar button or click on existing ruby node.
- Core Lexical changes (
LexicalEvents.ts,LexicalUtils.ts): Modifies$handleInput,$handleCompositionStart,$onCompositionEndImpl, and$updateTextNodeFromDOMContentto support composition on token nodes — redirecting composed text to adjacent TextNodes instead of mutating the token.
What I checked
- Architecture: Token-mode TextNode with wrapper DOM and DOMSlot is the right pattern for inline decorators that carry text. The
$config()protocol usage is correct. - Arrow navigation: The
$skipRubyOnArrowlogic correctly walks consecutive ruby groups and handles boundary conditions (ruby as first/last/only child → moves to parent element point). - Composition handling: The Safari IME composition guard (
$nudgeOffRubyskips when composing,$updateTextNodeFromDOMContentearly-returns for composing tokens) is critical — prevents data loss during CJK input. - Core changes: The modifications to
$onCompositionEndImpl(now returnsbooleanto indicate token-redirect) and$handleInput(newisCompositionOnTokenguard) are well-scoped. The token redirect at composition-end correctly usesselection.insertText(data)which handles the token boundary insertion natively. - Test coverage: Excellent — 764-line e2e spec (20+ tests), 1395-line unit test, 534-line composition test. Covers arrow skip, shift+arrow, backspace, copy/paste, toggle on/off, serialization, exportDOM, consecutive rubies, boundary cases, guard conditions.
CI Status — ❌ TWO FAILURES
-
Integrity (lint):
no-shadowerror inRubyExtension/index.tsline 229 — variableselshadows theselimport from@lexical/html. Easy fix: rename the local variable (e.g.,domSelornativeSel). -
E2e tests (11 failures): Multiple Ruby e2e tests failing with assertion errors — "Ruby DOM has wrapper span with inner annotated span", "Arrow left skips over ruby node", etc. These are the PR's own new tests failing, suggesting the floating editor or DOM structure in the real browser environment differs slightly from what the tests expect. Needs investigation.
www compatibility
No www compat concerns — all playground-only changes except the core LexicalEvents.ts / LexicalUtils.ts modifications. The core changes are additive (new code paths for token+composing) and don't alter existing behavior for non-token nodes. The $onCompositionEndImpl return type change from void to boolean is internal (not exported).
Suggestions
- Fix the lint
no-shadowerror (rename localseltonativeSelon line 229 of RubyExtension/index.ts) - Investigate the 11 e2e test failures — likely a timing or DOM structure mismatch
- Consider whether the core
LexicalEvents.tschanges should be in a separate PR for easier review/rollback (they're the highest-risk part)
Overall excellent work — comprehensive feature with thorough test coverage and proper IME handling. Just needs the CI fixes.
|
IME work is always a handful, but this ruby one was something else 😂 |
etrepum
left a comment
There was a problem hiding this comment.
Haven't had a chance to take a close look at the code yet but the functionality here is really cool! Seems to work well, was surprised that copy and paste of ruby html from wikipedia worked perfectly.
- Copy format/style from source text when creating ruby via $toggleRuby - Add role="group" and aria-label to ruby DOM wrapper for screen readers - Add aria-labels to floating editor input and buttons - Add Enter/Space keyboard activation to floating editor buttons
When IME composition starts on a token or segmented TextNode (e.g. MentionNode, RubyNode), the browser writes composed text directly into the node's DOM — breaking segmented nodes (entity destruction) and losing input on token nodes (revert on compositionEnd). Add a guard in $handleCompositionStart so that composition on these restricted nodes triggers COMPOSITION_START_CHAR insertion, which creates an adjacent TextNode via the existing insertText boundary logic and redirects composition there. Fixes facebook#6296.
5bf9e28 to
16e6249
Compare
etrepum
left a comment
There was a problem hiding this comment.
Overall this works great, but the tests have some bad practices and the playground could be configured a bit more cleanly
| import {MentionNode} from './MentionNode'; | ||
| import {PageBreakNode} from './PageBreakNode'; | ||
| import {PollNode} from './PollNode'; | ||
| import {RubyNode} from './RubyNode'; |
There was a problem hiding this comment.
There isn't any reason to update this file for nodes that are configured by extensions
There was a problem hiding this comment.
Done — removed RubyNode from PlaygroundNodes.
| editor = createTestEditor({nodes: [RubyNode]}); | ||
| registerRichText(editor); | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| extensionCleanup = (RubyExtension as any).register(editor); |
There was a problem hiding this comment.
we should really just be using the extension instead of doing weird stuff like this
There was a problem hiding this comment.
Done — both test files now use buildEditorFromExtensions(RubyExtension).
| editor = createTestEditor({nodes: [RubyNode]}); | ||
| registerRichText(editor); |
There was a problem hiding this comment.
these tests should also be using the extension
There was a problem hiding this comment.
Done — same as above.
| export type SerializedRubyNode = Spread< | ||
| { | ||
| annotation: string; | ||
| }, | ||
| SerializedTextNode | ||
| >; | ||
|
|
There was a problem hiding this comment.
These serialization types aren't very useful in modern lexical, we don't need to define them. It can be entirely inferred by typescript. Also it's wrong because annotation is actually optional, won't be serialized if it's the default value.
There was a problem hiding this comment.
Done — removed the type entirely.
| }); | ||
| } | ||
|
|
||
| constructor(text: string = '', key?: import('lexical').NodeKey) { |
There was a problem hiding this comment.
type NodeKey should simply be imported, or you can eliminate the custom constructor entirely by having $createRubyNode do setMode('token')
There was a problem hiding this comment.
Done — eliminated the custom constructor. $createRubyNode uses $create(RubyNode).setMode('token').setAnnotation(annotation).
| editor: LexicalEditor, | ||
| target: EventTarget | null, | ||
| ): string | null { | ||
| if (!(target instanceof HTMLElement)) { |
There was a problem hiding this comment.
| if (!(target instanceof HTMLElement)) { | |
| if (!isHTMLElement(target)) { |
| editorElement.addEventListener('focusout', handleBlur); | ||
| return () => { | ||
| editorElement.removeEventListener('focusout', handleBlur); | ||
| }; |
There was a problem hiding this comment.
| editorElement.addEventListener('focusout', handleBlur); | |
| return () => { | |
| editorElement.removeEventListener('focusout', handleBlur); | |
| }; | |
| return registerEventListener(editorElement, 'focusout', handleBlur); |
| if (prevRootElement) { | ||
| prevRootElement.removeEventListener( | ||
| 'compositionstart', | ||
| checkCompositionInRuby, | ||
| true, | ||
| ); | ||
| prevRootElement.removeEventListener( | ||
| 'compositionupdate', | ||
| checkCompositionInRuby, | ||
| true, | ||
| ); | ||
| prevRootElement.removeEventListener( | ||
| 'compositionend', | ||
| onCompositionEnd, | ||
| true, | ||
| ); | ||
| } | ||
| if (rootElement) { | ||
| rootElement.addEventListener( | ||
| 'compositionstart', | ||
| checkCompositionInRuby, | ||
| true, | ||
| ); | ||
| rootElement.addEventListener( | ||
| 'compositionupdate', | ||
| checkCompositionInRuby, | ||
| true, | ||
| ); | ||
| rootElement.addEventListener( | ||
| 'compositionend', | ||
| onCompositionEnd, | ||
| true, | ||
| ); | ||
| } |
There was a problem hiding this comment.
| if (prevRootElement) { | |
| prevRootElement.removeEventListener( | |
| 'compositionstart', | |
| checkCompositionInRuby, | |
| true, | |
| ); | |
| prevRootElement.removeEventListener( | |
| 'compositionupdate', | |
| checkCompositionInRuby, | |
| true, | |
| ); | |
| prevRootElement.removeEventListener( | |
| 'compositionend', | |
| onCompositionEnd, | |
| true, | |
| ); | |
| } | |
| if (rootElement) { | |
| rootElement.addEventListener( | |
| 'compositionstart', | |
| checkCompositionInRuby, | |
| true, | |
| ); | |
| rootElement.addEventListener( | |
| 'compositionupdate', | |
| checkCompositionInRuby, | |
| true, | |
| ); | |
| rootElement.addEventListener( | |
| 'compositionend', | |
| onCompositionEnd, | |
| true, | |
| ); | |
| } | |
| if (rootElement) { | |
| return registerEventListeners( | |
| rootElement, | |
| { | |
| compositionend: onCompositionEnd, | |
| compositionstart: checkCompositionInRuby, | |
| compositionupdate: checkCompositionInRuby, | |
| }, | |
| true, | |
| ); | |
| } |
| DragDropPasteExtension, | ||
| EmojisExtension, | ||
| MentionsExtension, | ||
| RubyExtension, |
There was a problem hiding this comment.
This should be in PlaygroundRichTextExtension because it doesn't really work unless it's rich text
| selection.anchor.set(compositionKey, offset, 'text'); | ||
| selection.focus.set(compositionKey, offset, 'text'); | ||
| selection.insertText(data); |
There was a problem hiding this comment.
| selection.anchor.set(compositionKey, offset, 'text'); | |
| selection.focus.set(compositionKey, offset, 'text'); | |
| selection.insertText(data); | |
| node.select(offset, offset).insertText(data); |
… updateDOM
- Move RubyNode and FloatingRubyEditor into RubyExtension folder
- Remove RubyNode from PlaygroundNodes (extension handles registration)
- Move RubyExtension to PlaygroundRichTextExtension (rich-text only)
- Replace custom constructor with $create(RubyNode).setMode('token')
- Fix updateDOM: use $getStateChange + prevNode.__text (getters defer to latest)
- Add version?: NodeStateVersion param to getAnnotation
- Delete SerializedRubyNode type (TypeScript infers it)
- Use isHTMLElement, registerEventListener, registerEventListeners utils
- Convert tests to buildEditorFromExtensions pattern
- Simplify LexicalEvents token redirect: node.select(offset, offset).insertText(data)
…click handling - Skip keydown during IME composition (isComposing guard) to prevent composed text from leaking into the contenteditable on Enter - Replace manual DOM walk (getRubyNodeKeyFromDOM) with $getNearestNodeFromDOMNode - Add isEditorPointerDownRef to suppress focusout during pointer interaction - Defer focusout close via rAF when relatedTarget is null (Firefox) - Guard $nudgeOffRuby with isMouseDown flag (mouseup on document) - Use requestAnimationFrame for editor.focus() after submit/delete - Fix button sizing and icon alignment in floating editor CSS - Use mask-image for ruby toolbar icon
…ce token invariant
| return; | ||
| } | ||
| requestAnimationFrame(() => { | ||
| if (editorElement.contains(document.activeElement)) { |
There was a problem hiding this comment.
Are these document references correct given the possibility of iframes and shadow roots?
There was a problem hiding this comment.
No, they weren't — switched all three document references to ownerDocument for iframe/shadow root compatibility.
The editor.read() call inside the CLICK_COMMAND handler (which already runs inside editor.update()) prevented downstream handlers from establishing NodeSelection on decorator clicks (images, cards, etc.). Also replace document.activeElement with ownerDocument.activeElement for iframe/shadow root compatibility.
|
All feedback addressed — ran into some issues along the way so it took a bit longer than expected 😅
|
…on + shadow-aware floating editor focus Rewrites RubyExtension's arrow navigation on the NodeCaret APIs: $walkPastRubyChain's hand-rolled direction lambdas become $getSiblingCaret / getNodeAtCaret over a CaretDirection, and $skipRubyOnArrow's duplicated edge-detection and destination branches collapse into a single path: $caretFromPoint + $isExtendableTextPointCaret detect a point at the edge of a position adjacent to a ruby (now including element points, which previously fell through to native handling), and the landing is one $setPointFromCaret on the flipped edge caret — the same boundary materialized off the token node (the near edge of the adjacent text node, or the parent element point when there is no sibling). The backspace handler uses the same caret primitives, generalizing the anchor-offset-0 check to element points such as the parent-boundary position that arrow navigation itself creates, and the two arrow command registrations are deduplicated into a single factory over [command, direction] pairs. The offset>=1 landing for shift-extension forward from a caret on the ruby is preserved, and its comment corrected: it is not Safari-specific. A focus at offset 0 of the text node after the ruby is resolved back onto the ruby end by the DOM selection round-trip (reproduced in Chromium), so without it every further Shift+Right re-lands at the same boundary and the selection stops growing. In FloatingRubyEditor, ownerDocument.activeElement fixes the cross-document (iframe) case but still reports the shadow host when the editor UI is rendered inside a shadow root, so the focusout fallback closed the popup while its input still had focus. Both focus checks now use getActiveElement (the DocumentOrShadowRoot-scoped active element, same pattern as FloatingLinkEditorPlugin). Also extracts $unwrapRubyNode (replace a ruby with an equivalent plain TextNode preserving format/style), previously duplicated across $toggleRuby, $unwrapRubiesInSelection, and handleDelete. New coverage for behavior this change introduces or relies on: - unit: element-point arrow handling (skips a chain the point faces; falls through to native when the chain is behind the point, so the caret can leave the paragraph) and element-point backspace; dispatched inside the update because a committed element point can be re-resolved onto the adjacent text node by the DOM selection round-trip - e2e: repeated Shift+Right across a ruby must keep extending the selection (fails without the offset>=1 landing, in Chromium too) - e2e (isShadowDOM mode): a focusout with no relatedTarget while the popup's input has focus must not close the floating ruby editor (fails against document/ownerDocument-based activeElement checks), and clicking back into the editor still closes it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RLAwZEC8LrpGQ9LzPdcH2
|
@mayrang I worked with claude to simplify some of the code to use the caret APIs and add a little more test coverage, let me know if you think something else should be addressed before merge Suggested changes: `claude/ruby-extension-nodecaret-h1k1if` (1 commit on top of `0a8698d`)RubyExtension: rewrite arrow navigation on NodeCaret APIs
FloatingRubyEditor: shadow-safe focus checks
Dedup
Tests added
Verification
|
|
Reviewed and tested the NodeCaret refactor + shadow-aware focus commit locally — unit tests pass, tsc clean. LGTM, ready to merge whenever you are. |
Description
Adds a playground-level
RubyNode(extendsTextNodein token mode) with full keyboard navigation, IME composition support, and a floating annotation editor. The node renders as<ruby><rt>on export and imports the same structure back.What changed
RubyNode (
plugins/RubyExtension/RubyNode.ts) — a token-modeTextNodesubclass with anannotationstate (viacreateState). The DOM is a wrapper<span>containing an inner<span data-ruby-annotation="...">so the annotation text renders via CSS::before.exportDOMproduces standard<ruby>...<rt>...</rt></ruby>.RubyExtension (
plugins/RubyExtension/index.ts) — registers arrow-key, backspace, and composition handlers. Arrow keys skip over consecutive ruby groups atomically — ruby base text is in token mode, so the cursor can't meaningfully stop on it (any keystroke would be redirected to a neighbor). Skipping the entire contiguous group avoids a confusing dead-stop. Shift+arrow extends selection past them. ASELECTION_CHANGEhandler nudges collapsed cursors off non-composing ruby nodes to prevent the caret from landing inside a token.Floating editor (
plugins/RubyExtension/FloatingRubyEditor.tsx) — follows theFloatingLinkEditorPluginpattern. The editor appears anchored to the selection when creating new ruby (toolbar trigger) or to the ruby DOM element when clicking an existing one. Enter confirms, Escape dismisses. While active, theFloatingTextFormatToolbaris suppressed so the two don't overlap.Core changes (
LexicalEvents.ts,LexicalUtils.ts) — targeted fixes for IME composition on token-modeTextNodesubclasses:$onCompositionEndImplnow detects when composition ends on a token node and redirects the composed text to the adjacentTextNodeviaselection.insertText, instead of lettingmarkDirtysilently discard it. Uses the actualselection.anchor.offsetto decide direction — offset 0 redirects to the previous sibling, offset=textLen to the next.$handleInputskips$shouldPreventDefaultAndInsertTextduringinsertCompositionTexton token nodes, so the browser's native composition UI stays intact until composition ends.$updateTextNodeFromDOMContentbails early when a composing token node's DOM is synced, preventing the reconciler from reverting mid-composition input.$handleCompositionStartnow redirects composition away from token/segmented nodes by insertingCOMPOSITION_START_CHAR, which triggers the existinginsertTextboundary logic to create an adjacentTextNode. This prevents composition from starting inside nodes that can't accept text modifications — the root cause of Bug: input Chinese character after mention break mention entity and repeat characters #6296 (mention entity destruction on CJK input). Combined with (1), this provides two layers: pre-composition redirect at start, and post-composition redirect at end as a fallback.These core changes are generic — any token-mode or segmented
TextNodesubclass benefits from them, not justRubyNode.Toolbar
The
ルビkatakana text button is replaced with an SVG icon (ruby.svg) consistent with other toolbar icons. The button only activates when text is selected; clicking with an existing ruby in the selection removes it.Closes #7787, #6296
Test plan
RubyNode.test.ts) — arrow skip, Shift+arrow selection, consecutive ruby group walk, line boundary fallback, backspace, guard conditions, HTML import rule.RubyComposition.test.ts) — composition end redirect on token nodes, mid-composition DOM stability, composed text placement, edge cases (solo ruby, paragraph-first ruby, offset 0 redirect).Ruby.spec.mjs) across Chromium/Firefox/WebKit — insert, DOM structure, arrow navigation, backspace/delete, select-all, toggle off, copy-paste, JSON round-trip, exportDOM, Shift+arrow selection, line boundary navigation.tsc --noEmit/ prettier / eslint clean.Manual test scenarios (all three browsers)
Setup — paste this structure into each test:
Arrow navigation
前|漢→ Right →漢|字(one move across ruby boundary)字|後→ Right →後|(ruby → plain text)|前→ Right →前|漢(plain text → ruby)漢|字→ Left →前|漢(ruby boundary, backward)Typing at boundaries
前|漢(ruby start) → typeあ→ inserted outside ruby, after前字|後(ruby end) → typeあ→ inserted outside ruby, before後Backspace / Delete
前|漢→ Backspace →前deleted字|後→ Delete →後deleted漢→ Backspace → ruby node removedSelection + delete
漢字→ Delete → both rubies deleted,前後remains漢字後→ Delete → ruby + plain text deleted,前remainsCopy / paste
<ruby>HTML paste → converted to RubyNode<rp>tags in pasted HTML → ignored correctlyUndo / Redo
Ruby create / remove
Serialization
type: "ruby",annotation: "かん",text: "漢"<ruby>漢<rt>かん</rt></ruby>Edge cases
|漢→ no movement字|→ no movementDesign notes
Package placement:
RubyNodelives inlexical-playground, not in a dedicated@lexical/rubypackage. The node and extension are structured for extraction if there's interest —RubyNodehas no playground dependencies, andRubyExtensiononly depends on@lexical/htmlfor the import rule.Token-mode TextNode vs. inline ElementNode: The original design was an inline
ElementNodesubclass (likeLinkNode) with the base text as a childTextNode. During implementation this turned out to be impractical for several reasons:TextNodeinside an inlineElementNodecreates a cascade of issues with Lexical's reconciler and browser selection normalization at element boundaries. The reconciler tries to restore DOM state mid-composition, and Safari in particular normalizes the cursor back into the element acrosscompositionstart/compositionend. With token mode, composition is handled by the core token-redirect path (which this PR fixes) rather than fighting per-element boundary conditions.<ruby>behaves in HTML — base text and annotation are a single unit.The tradeoff is that formatting the base text independently (bold only on the kanji) isn't possible without unwrapping, but this matches standard
<ruby>semantics.Composition visual limitation (Safari): When IME composition starts immediately after a ruby node, Safari normalizes the cursor into the preceding ruby's inner
<span>rather than the adjacentTextNode. The composed text therefore appears inside the ruby DOM. Since the annotation is rendered via::afteron the same element, showing both the annotation and the composing text in the same space would overlap. The extension detects this viacompositionstart/compositionupdatelisteners and adds a--composingCSS class that hides the::afterannotation untilcompositionend. The result is that the previous ruby's furigana temporarily disappears during the first character's composition, then reappears once composition ends and the text is redirected to its ownTextNode.We tried several approaches to avoid this:
TextNodeprogrammatically before composition: Safari re-normalizes it back into the ruby span, undoing the move.TextNodebetween the ruby and the next node to give Safari a landing target: the gap triggers$normalizeTextNodewhich merges it into the adjacent text, and if guarded withtoggleUnmergeable(), a transform loop removes and re-creates it indefinitely.compositionstart: breaks the IME state entirely, producing garbled input.Hiding the annotation during composition was the least-bad option — functional correctness is preserved, and the visual disruption lasts only for the first composed character.